基于 V8 引擎实现 JS 调试的 DevTools

如何对 JS 引擎进行 JS 调试?本文以 V8 引擎为例,阐述如何搭建一个 JS 调试的 DevTools,并实现 V8 调试 JS 的能力,最后再对比不同的 JS 引擎在 JS 调试上的差异和调试方式。

一、DevTools 搭建

实现一个调试工具 DevTools,需要有呈现用户的调试器 Frontend,调试后端采集数据的 Backend,以及衔接 Frontend 与 Backend 之间的调试通道和协议。我们对 JS 的调试工具的实现可以如下所示:

v8inspector

1、调试器 Frontend 和调试协议

它们都会使用 Chrome DevTools 的 Frontend 和 Protocol,可以直接兼容移动端嵌入的 V8 引擎。区别在于移动端的 V8-Inspector 实现的协议是浏览器 Chromium 的子集。协议可见:https://chromedevtools.github.io/devtools-protocol/v8/:

相比 Chromium,V8 只实现了 Console、Source、Memory、CPU Profile 四个 Tab 的调试能力,当然其他的协议未实现是因为 V8 只是负责 JS 执行,不负责页面的渲染,所以对于其他的调试,我们可以在调试后端 Backend 上基于 CDP 的协议的基础上进行数据采集实现。

2、调试后端 Backend

App Backend 端接收 Chrome DevTools Protocol 调试消息,对于 V8 的支持的 CDP 协议直接转发给 Inspector,但对于不支持的 CDP 协议看情况可以采集数据进行适配。

3、调试通道

Chrome DevTools Frontend 会作为 websocket client 去连接外部的 websocket server,而连接的 websocket client url 也是外部 http 服务提供的,如下图所示:

chrome-inspec

在 Chrome 上设置 remote-debuging-portinspect 页面就会从 remote-debuging-port/json http 服务上把所有的调试项展示出来,在点击某个 Target 进入 DevTools Frontend 时就会使用 ws 连接。

websocket server 可以直接设置在 App Backend 端,也可以放在 PC 本地服务上,甚至放在公网部署上。

  • 本地调试:Android 使用 adb forward /iOS 使用 usbmuxd 建立 pc/手机端的数据通信,当然也都可以通过无线局域网代理的方式通信。
  • 公网调试:websocket server 部署在公网上,可以去掉 usb 线的连接或局域网代理的设置。

二、V8 概念

在了解 V8 调试之前,先来熟悉 V8 的一些概念:

v8 概念

Isolate

V8 的引擎实例,就是一个 JS 的虚拟机并且有自己的 heap,不同的 Isolate 之间不共享任何资源。一个 Isolate 可对应一个或多个线程,但在同一时刻只能被一个线程进入。

Context

JS 代码的执行环境,Context 中包含了 JavaScript 内建函数、对象等,不同的 Context 的 JavaScript 是沙箱隔离,默认不能互相访问,但可以通过 SetSecurityToken 设定安全令牌进行通信。需要注意的是一个 Isolate 同一时刻只能对应一个线程,那在多 Context 的场景下,也只有一个 Context 运行。

Handle

V8 的内存分配都是在 V8 的 heap 上分配的,方便对所有对象进行跟踪,Handle 是对 Heap 对象的引用,

Handle 分为 Local(局部)和 Persistent(全局)两种:

  • Local Handle 使用 HandleScope 来管理
  • Persistent 使用 Persistent::New()Persistent::Release() 来创建和释放。

HandleScope

HandleScope 用来管理 Handle 的容器,对 Handle 的创建和释放都可以通过 HandleScope 提供的 Handle stack 来管理

使用示例

1
2
3
4
5
6
v8::HandleScope handle_scope(isolate);
v8::Local<v8::Context> context =
v8::Local<v8::Context>::New(isolate, ctx->context_persistent_);
v8::Context::Scope context_scope(context);
inspector_->contextCreated(v8_inspector::V8ContextInfo(
context, inspector_context->GetContextGroupId(), v8_inspector::StringView(kProjectName, arraysize(kProjectName))));
  • 创建 HandleScope,内部是有一个 Handle Stack 来管理 Handle 的对象
  • 创建一个本地的 Context,它分配在 Handle Stack 上,并指向 V8 heap 真实的 Context 对象
  • 切换到当前 Context 环境上,在 Context 里编译和执行 JS 相关逻辑

三、V8 Inspector 调试实现

根据上边介绍的 V8 Isolate、Context 的概念,我们结合 Inspector 实现需要的类可用以下架构来实现:

inspector 概览

Isolate 对应一个 V8 Engine,可同时支持多个 Context 业务 JS 执行,这里 Inspector 是跟 Isolate 一一对应的,那单个 Isolate 下的多个 Context 调试需要通过 Inspector 创建的 SessionChannel 来跟 DevTools Frontend 通信。

1、v8 调试框架实现

要实现 Inspector 需要传入 v8_inspector::V8InspectorClient 的实现类,我们用它来对 Inspector 进行创建和管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// v8_inspector::V8InspectorClient impl
void V8InspectorClientImpl::CreateInspector() {
v8::HandleScope handle_scope(isolate);
inspector_ = v8_inspector::V8Inspector::create(isolate, this);
}

void V8InspectorClientImpl::ConnectSession() {
// 实现自己的 Channel
channel_ = std::make_unique<V8ChannelImpl>();
// 创建 session,两个关键参数:实现的 channel 和与 Context 关联的 context_group_id
session_ = inspector_->connect(1 /**context_group_id**/, channel_.get(), v8_inspector::StringView());
}

void V8InspectorClientImpl::CreateContext() {
v8::HandleScope handle_scope(isolate);
v8::Local<v8::Context> context =
v8::Local<v8::Context>::New(isolate, context_persistent_);
v8::Context::Scope context_scope(context);
// context 关联,将 context_group_id 为 1 的 context 和 name 信息传入,消息将会分发到 context_group_id 为 1 的 session
inspector_->contextCreated(v8_inspector::V8ContextInfo(
context, 1, v8_inspector::StringView(kProjectName, arraysize(kProjectName))));
}

void V8InspectorClientImpl::DestroyContext() {
v8::HandleScope handle_scope(isolate);
v8::Local<v8::Context> context =
v8::Local<v8::Context>::New(isolate, context_persistent_);
v8::Context::Scope context_scope(context);
inspector_->contextDestroyed(context);
}

void V8InspectorClientImpl::SendMessageToV8(const std::string str) {
v8_inspector::StringView message_view = v8_inspector::StringView(reinterpret_cast<const uint16_t*>(str.c_str()), str.length());
// frontend 的 CDP 消息可以通过 session 转给 v8
session_->dispatchProtocolMessage(message_view);
}
  • 创建 Inspector:使用当前 Isolatev8_inspector::V8InspectorClient 实现类
  • 创建 Session:使用 v8_inspector::V8Inspector::Channel 的实现类和当前与 JS 执行的 Context 关联的 context_group_id
  • contextCreated:使用前边创建的 Sessioncontext_group_idContext 进行 Session 关联,这样 Inspector 才能将某个 Context 的调试消息分发给某个 Session
  • dispatchProtocolMessagesession 关联调试的 context 后,就可以把 Frontend 的 CDP 调试协议消息转发给 V8
  • contextDestroyed:当 JS 环境的 Context 销毁后把调试的 Context 也就行销毁

对于转给 V8 消息的回包和 V8 的主动通知,都是通过传入 Sessionv8_inspector::V8Inspector::Channel 实现来回调,我们可以在这两个函数里可以把消息转发给 Frontend:

1
2
3
4
5
6
7
8
9
10
11
// v8_inspector::V8Inspector::Channel impl
void V8ChannelImpl::sendResponse(
__unused int callId,
std::unique_ptr<v8_inspector::StringBuffer> message) {
SendToFrontend(std::move(message));
}

void V8ChannelImpl::sendNotification(
std::unique_ptr<v8_inspector::StringBuffer> message) {
SendToFrontend(std::move(message));
}

2、v8 调试还需要解决的问题

上边一顿操作后看起来可以与 Frontend 进行调试了,但在断点时会出现以下问题:

v8-inspect问题

  • 获取当前断点的对象属性,-32603,Internal error
  • 获取当前指向的对象调用栈,直接报栈溢出 RangeError: Maximum call stack size exceeded
  • 断点调试第一个断到后,下一步调试或step over 不生效

后面检查发现还需要在 v8_inspector::V8InspectorClient 实现类里对以下两个函数进行处理:

1
2
3
4
5
void V8InspectorClientImpl::runMessageLoopOnPause(int contextGroupId) {
}

void V8InspectorClientImpl::quitMessageLoopOnPause() {
}

runMessageLoopOnPause 在 V8 断点时触发,这时需要接入方同步消费 Frontend 传入的 CDP 消息,否则 V8 就丢失了这些消息,如运行时的对象获取等。在结束断点时触发 quitMessageLoopOnPause ,此时停止同步消费,恢复线程的正常执行。

四、V8 Inspector 的 JS 调试扩展

V8 Inspector 实现的协议只支持 Console/Sources/Memory/CPU Profile,但对于 Chrome DevTools Frontend 的其他调试如何扩展呢?

v8-inspector扩展

  • Elements、Network、Application:这些与渲染框架有关,可以根据 CDP 协议,如需要适配 Elements 的 DOM/CSS/Page,采集渲染框架的数据返回给 Frontend 即可复用 DevTools Frontend 的调试部分。
  • Performance:JS 的性能仍然需要去 V8 引擎采集,但 V8 Inspector 并未实现,此时需要根据 V8 暴露的头文件如 v8-tracing 采集对应的性能数据返回。

五、JS 引擎调试对比

我们对比业界 JS 引擎的调试,可以根据是否支持调试,调试限制条件这些整理如下:

JS引擎 是否实现的调试协议 调试接口暴露 调试限制
V8 Inspector、Tracing 等 无限制,实现 Inspector 接口
JSC iOS 系统集成,未暴露 Debugger 相关 仅限开发者证书的包可调试
QuickJS 开源集成
  • V8 引擎实现了 Inspector 相关的调试协议,业务接入只需要按本文讲到的方式实现即可支持 Chrome DevTools Frontend 来调试。
  • JSC 引擎会被 iOS 系统以 Framework 集成,在携带设备 UUID 的开发者证书编译的包上,可以使用 safari 来调试,但对于未添加设备的开发证书或其他企业证书都是不能调试的。
  • QuickJS 引擎并未实现调试协议,如果要支持 JS 调试,还需要接入方按需实现。

对于一个跨平台框架,如 React Native、Hippy、小程序而言,好的调试体验是是屏蔽不同系统之间的差异,都可用一个调试工具来进行 JS 调试。这时对于 iOS JSC 来说,就存在两个问题,一个是 iOS 包的限制,另一个是和 Android 调试的工具存在不一致,此时也有几种解决方式:

  • 小程序/React Native:逻辑层的 JS 代码放到 PC 端的 Chrome Web Worker 中执行,使用到的渲染层同步/异步接口统一使用异步方式通过 websocket 传到手机端渲染。存在问题主要是 V8/JSC 引擎执行差异如日期、重复定义 props 等,还有同步接口转异步的性能损耗。
  • Hippy:统一使用 Chrome DevTools Frontend 来调试 V8/JSC,中间搭建一层协议转换把 V8 转换为 JSC 的调试协议,再通过 iOS 提供的系统调试通道转发给 JSC。存在的问题主要是 JSC 调试对于 iOS 包的开发者证书限制。
知道是不会有人点的,但万一被感动了呢?